Skip to content

Speed up Application/Configurable instance startup - #956

Open
Carreau wants to merge 4 commits into
mainfrom
claude/traitlets-startup-performance-aykeja
Open

Speed up Application/Configurable instance startup#956
Carreau wants to merge 4 commits into
mainfrom
claude/traitlets-startup-performance-aykeja

Conversation

@Carreau

@Carreau Carreau commented Jul 21, 2026

Copy link
Copy Markdown
Member

Speeds up the runtime cost of building an Application and its many
Configurable sub-components at startup — the pattern used by IPython, Jupyter
server, ipywidgets, etc., which define hundreds of HasTraits subclasses at
import and instantiate a large component graph at launch.

The import-time half of this work ("Defer heavy imports to speed up import time")
has already landed separately on main (626bbe5), so this PR is now purely the
runtime optimizations. Each commit is independent and can be reviewed (or pulled)
on its own.

Commits

  1. Reuse the class-namespace walk across the two metaclasses.
    MetaHasDescriptors.setup_class already walks the full class namespace via
    getmembers(cls); it now returns that (name, value) list so
    MetaHasTraits.setup_class can reuse it to find TraitType members instead
    of doing a second, redundant dir(cls) + getattr walk over every class.
    Same members in the same order, so semantics are identical (getmembers
    already skips members whose getattr raises, which is what the removed
    try/except handled).
    → class definition ~118 µs → ~96 µs per class.

  2. Cache metadata-filtered class_traits()/traits() results per class.
    Filtering by metadata (e.g. class_traits(config=True)) was recomputed from
    scratch on every call — the single largest cost of Application startup
    (~25-30%), invoked ~45× per startup for results that are static per class
    (from Application._classes_with_config_traits,
    KVArgParseConfigLoader._add_arguments, and each Configurable._load_config).
    Both methods now delegate to a shared classmethod that memoizes the filtered
    dict per class and returns a .copy(), preserving the existing "fresh dict"
    contract (the cached dict never escapes by reference). cls._traits is frozen
    after class creation (add_traits() builds a new class rather than mutating),
    so the only way a filtered result can change is a post-hoc metadata mutation
    via tag()/set_metadata(); those bump a module-level generation counter and
    stale cache entries are recomputed. Only constant (non-callable, hashable)
    filters are cached; callable predicates stay on the uncached path.
    class_traits(config=True) ~8.3 µs → ~1.3 µs per call.

    Reviewer note: the cache is invalidated by the supported post-construction
    metadata APIs (tag()/set_metadata()). Mutating trait.metadata as a raw
    dict after the class has already been queried is not reflected until the next
    generation bump — a pattern not used in traitlets and vanishingly rare in
    practice, but called out for awareness.

  3. Skip redundant re-validation of constructor kwargs.
    HasTraits.__init__ validated every trait kwarg twice: once via setattr in
    the fast loop, then again via _cross_validate + set_trait. The second pass
    is only needed for traits that actually have a cross-validator; the loop now
    guards on the same condition _cross_validate itself uses and, for traits
    without one, records the already-stored (possibly coerced) value for the
    notification instead of re-validating. Notification payloads are byte-for-byte
    identical.
    → instantiation with kwargs and no cross-validators ~1.2-1.4× faster.

  4. Skip config loading for Configurables with no matching config.
    Configurable._load_config computed traits(config=True) and entered
    hold_trait_notifications() unconditionally, even for the many leaf
    Configurables whose config has no matching keys. It now computes my_config
    first and returns early when empty. Also removes a dead
    section_names = self.section_names() local that was computed (twice per
    instance) but never used.
    → ~1.2× on leaf Configurables with no matching config.

End-to-end

On a synthetic app modeled on Jupyter/IPython scale (12 Configurable
components, 373 traits, config applied), Application() construct + initialize()
drops ~2196 µs → ~1740 µs (~21%) on Python 3.11. The wins compound with the
number of Configurables built and traits scanned at startup.

All changes are behavior-preserving. Full test suite passes (including new tests
covering the cache's invalidation/copy contract, unhashable/callable filters, and
the deferred-import cold paths), along with mypy and ruff.

@Carreau

Carreau commented Jul 25, 2026

Copy link
Copy Markdown
Member Author

It does not affects ipython startup visibly, but does hove an effect on raw 'python -c "import traitlets.config"'

@Carreau
Carreau force-pushed the claude/traitlets-startup-performance-aykeja branch 2 times, most recently from 09e6089 to 690de14 Compare August 3, 2026 19:13
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

@Carreau
Carreau force-pushed the claude/traitlets-startup-performance-aykeja branch from 690de14 to f1f33a9 Compare August 3, 2026 19:22
claude added 4 commits August 4, 2026 07:20
MetaHasDescriptors.setup_class already walks the full class namespace via
getmembers(cls) to initialize descriptors; it now returns that (name, value)
list so MetaHasTraits.setup_class can reuse it to find TraitType members
instead of performing a second, redundant dir(cls) + getattr walk over every
class. Same (name, value) pairs in the same order, so semantics are identical
(getmembers already skips members whose getattr raises AttributeError, which
is exactly what the removed try/except handled).

Measured (Python 3.11): class definition ~118us -> ~96us per class, which adds
up for applications that define hundreds of HasTraits subclasses at import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VaKDJ3fpGf7anQeYeBsJbk
Filtering traits by metadata (e.g. class_traits(config=True)) was recomputed
from scratch on every call — the single largest cost of Application startup
(~25-30%), invoked ~45x per startup for results that are static per class
(from Application._classes_with_config_traits, KVArgParseConfigLoader.
_add_arguments, and each Configurable._load_config).

class_traits()/traits() now delegate to a shared classmethod that memoizes the
filtered dict per class and returns a .copy(), preserving the existing
"fresh dict" contract — the cached dict never escapes by reference. cls._traits
is frozen after class creation (add_traits() builds a new class rather than
mutating), so the only way a filtered result can change is a post-hoc metadata
mutation via tag()/set_metadata(); those bump a module-level generation counter
and stale cache entries (older than the current generation) are recomputed.
Only constant (non-callable, hashable) filters are cached; callable predicates
stay on the uncached path.

Measured (Python 3.11): class_traits(config=True) ~8.3us -> ~1.3us per call.

Note: the cache is invalidated by the supported post-construction metadata APIs
(tag()/set_metadata()). Mutating trait.metadata as a raw dict after the class
has already been queried is not reflected until the next generation bump; this
pattern is not used in traitlets and is vanishingly rare in practice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VaKDJ3fpGf7anQeYeBsJbk
HasTraits.__init__ validated every trait kwarg twice: once via setattr in the
fast loop, then again via _cross_validate + set_trait. The second pass is only
needed for traits that actually have a cross-validator (@Validate handler or a
deprecated _<name>_validate method); for the common case with none it re-ran
validate() on an already-validated value.

The second loop now guards on the same condition _cross_validate itself uses
(key in self._trait_validators or a _<name>_validate attribute exists). For
traits without a cross-validator it records the already-stored (possibly
coerced) value for the notification instead of re-validating, so notification
payloads are byte-for-byte identical.

Measured (Python 3.11): instantiation with kwargs and no cross-validators
~1.2-1.4x faster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VaKDJ3fpGf7anQeYeBsJbk
Configurable._load_config computed traits(config=True) and entered
hold_trait_notifications() unconditionally, even for the many leaf
Configurables in an Application graph whose config has no keys matching the
instance. It now computes my_config first and returns early when it is empty,
before doing any of that work.

Also removes a dead `section_names = self.section_names()` local that was
computed (section_names() walks the MRO with issubclass checks, twice per
instance) but never used — _find_my_config recomputes it internally. The
section_names parameter is kept in the signature for backward compatibility.

Measured (Python 3.11): ~1.2x on leaf Configurables with no matching config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VaKDJ3fpGf7anQeYeBsJbk
@Carreau
Carreau force-pushed the claude/traitlets-startup-performance-aykeja branch from f1f33a9 to 4e69b41 Compare August 4, 2026 07:21
@Carreau Carreau changed the title Speed up startup: defer heavy imports and cut redundant class setup work Speed up Application/Configurable instance startup Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants